For years, Zone.js powered Angular’s “magic refresh,” keeping apps in sync without extra effort. It worked by patching async browser APIs and notifying Angular whenever something might have changed. While this made development smoother, it also came with trade-offs: unnecessary change detection cycles and debugging complexity. Now, Angular 20.2 marks a turning point. Zoneless mode is stable, opening the door to a leaner and more predictable way of building Angular apps.
What is Zone.js and How Does it Work?
Before we talk about going zoneless, let’s recall what Zone.js actually is and does. It’s a library that monkey-patches asynchronous browser APIs such as setTimeout, promises, DOM events, and HTTP requests. Each time one of these is completed, it notifies Angular that “something might have changed.” But Zone.js couldn’t provide details about what changed or where. As a result, Angular had to trigger change detection across the whole component tree to make sure the UI stayed in sync.
That trade-off defined much of the Angular developer experience. On the bright side, Zone.js made things feel almost magical, the UI updated automatically whenever async code finished, and you didn’t have to think about it. This simplicity was especially appealing in Angular’s early days, when developers could focus on building features instead of worrying about change detection triggers.
But the magic came with a price. Zone.js treated every async event as a possible change, which meant Angular often did more work than necessary. Over time, that extra overhead slowed apps down and made debugging harder.
For years, developers enjoyed the “magic” of Zone.js but also dealt with its drawbacks. Here is some good news: Angular has been evolving to eliminate this dependency, and with Angular 18, we see the first experimental steps toward a zoneless future.
Angular Zoneless – From Experimental to Stable
The idea of running Angular without Zone.js has been a long-awaited change in the framework’s evolution. Back in Angular 18, the team introduced the first experimental APIs for zoneless mode, which allowed us to explore a world where change detection was no longer tied to Zone.js patching every asynchronous operation in the browser.
With the release of Angular 20.2, these APIs became stable, and we can now confidently build production applications in zoneless mode. Instead of relying on Zone.js, we work with an explicit change detection model where updates are triggered by signals, template events, async pipes, and manual checks when necessary.
This naturally raises the next question: why should we go zoneless, and what do we actually gain by removing Zone.js from our applications?
Why go zoneless?
So why should we drop Zone.js now that we finally can? The main reasons come down to leaner applications, improved performance, and more predictable behavior.
Benefits of going zoneless
- Reduced bundle size and faster initial load: Without Zone.js, the bundle shrinks by about 33 KB. That’s not huge on its own, but it translates directly into a faster initial load, since the browser no longer has to download and parse the library.
Figure 1: Initial bundle size with Zone.js
Figure 2: Initial bundle size in zoneless app
- Better performance: Zone.js often triggered unnecessary change detection cycles, even when no data had changed. Zoneless mode removes that overhead. Change detection now runs only when it actually needs to, giving us more predictable and performant rendering.
- Easier debugging: With Zone.js gone, stack traces are no longer wrapped in Zone-specific frames. You get a full, accurate stack trace that points exactly to where something happened. No more extra noise. This makes debugging and profiling significantly easier.
- Full control over reactivity: In zoneless Angular, the developer explicitly decides when the UI should update. This is a major shift – instead of relying on Zone.js “magic,” you know exactly what triggers change detection and when it happens. That makes the app’s reactivity model both transparent and intentional.
Trade-offs to keep in mind
- You may need to adjust your mental model. Without automatic change detection, you must adopt a more deliberate strategy for updating the UI. That means paying more attention to using signals, async pipes, or calling markForCheck when necessary.
- Migration effort: migrating a large app can take time, especially if it’s heavily tied to Zone.js behaviors and doesn’t use signals and onPush change detection strategy.
Create a zoneless project
Starting a zoneless project is surprisingly simple. In Angular v20.2, you can enable zoneless mode directly when you are creating a new project using CLI:
Figure 3: Create zoneless project with zoneless flag
If you skip the flag, the Angular CLI will ask you a question during project setup:
Figure 4: Create zoneless app – CLI zoneless question
Select “Yes” and you will get a project fully Zone.js free!
Migration to zoneless
If you already have an Angular project and want to migrate to zoneless, the process takes a few steps:
- In app.config.ts, swap: provideZoneChangeDetection({ eventCoalescing: true }) → provideZonelessChangeDetection()
Figure 5: Switch to zoneless provider in app.config.ts
- Remove zone.js from angular.json build and test configs.
Figure 6: Remove zone.js from build config in angular.json
Figure 7: Remove zone.js and zone.js/testing from test config in angular.json
- Delete imports: import zone.js and import zone.js/testing.
- Uninstall Zone.js. Once nothing depends on it anymore, uninstall it.
Figure 8: Uninstall Zone.js
- Verify in the browser. Open your app in the browser, open the console, and type Zone. You should get an error: Zone is not defined. That confirms Zone.js has been fully removed.
Figure 9: Checking in the browser console if Zone.js is still available in the app
How Change Detection Works Without Zone.js
Once Zone.js is gone, Angular no longer “guesses” when to refresh the UI. Instead, the framework listens to specific, intentional triggers that tell it exactly when change detection should run. So what are the actual triggers that make Angular run change detection without Zone.js?
Change detection triggers
- Bound host or template event listeners
<button class="refill-button" (click)="refillRum()">Refill Barrel</button>
@HostListener('click')
refillRum(): void {
this.rumService.refillRum();
}
- Async pipe calls ChangeDetectorRef.markForCheck() under the hood whenever the observed value changes, ensuring your template reflects the new data.
@for(location of treasureLocations$ | async; track location.id) {
<!—Treasure location content -->
}
- Updating a signal used in a template
- ComponentRef.setInput(): When you programmatically set an input on a dynamically created component, Angular marks that view as dirty and schedules change detection.
- Manual call of ChangeDetectorRef.markForCheck(): While Angular handles change detection automatically in most cases, you can still force it with markForCheck(), ensuring Angular picks up changes it wouldn’t catch otherwise.
It’s important to understand that going zoneless doesn’t rewrite Angular’s change detection model from scratch. The two familiar strategies, Default and OnPush, are still in place, and their behavior hasn’t changed. What changed is when the change detection process starts.
- Default strategy: With the default mode, Angular still walks the component tree from top to bottom, checking each view to see if updates are needed. Zoneless or not, this part works exactly the same.
Figure 10: Default change detection – Trigger change detection by click event
In Figure 10, we see a zoneless application’s component tree, where all components use the default change detection strategy. When a user clicks a button inside one of the child components, the click event triggers change detection.
In the next step (Figure 11), Angular marks the component where the click happened, together with all of its ancestors up to the root, as dirty. Then Angular runs the change detection process (Figure 12).
The key difference is only in how change detection is triggered. Zone.js used to fire it on every patched async operation, while zoneless relies on explicit triggers like user events, signals, or async pipe.
Finally, it’s worth clarifying that Angular never “re-rendered” components. That’s a common misconception. Angular simply checks bindings, and if a change is detected, it updates only the affected DOM nodes.
Figure 11: Default change detection – Mark View Dirty
Figure 12: Default change detection – components checked
- onPush strategy: With OnPush, Angular checks only those components that have been explicitly marked as dirty.
Figure 13: onPush change detection – trigger change detection by click event
Let’s look at a mixed setup: some components use OnPush, others stick to the default strategy (Figure 13). A user clicks a button inside an OnPush component. Angular marks that component and its ancestors dirty, same as before (Figure 14).
Figure 14: onPush change detection – Mark View Dirty
But here’s the twist. With OnPush, Angular only checks components that are actually marked as dirty. If an OnPush component isn’t marked dirty, Angular skips it entirely, along with all of its children (Figure 15). In our case, the parent of the clicked component is OnPush and marked dirty, so Angular checks it, and because that parent has another child using the default strategy, that sibling gets checked as well.
Figure 15: onPush change detection – components checked
- Local change detection with OnPush + Signals: Imagine we have a component tree where all components use OnPush change detection and rely on signals in their templates (Figure 16).
Figure 16: “Local” change detection – onPush + signal change + async task
When an asynchronous task triggers a change in a signal, this update does not mark all ancestors as dirty. Instead, only the component consuming that signal (the “consumer”) is tagged as dirty.
Figure 17: “Local” change detection – Marking consumer dirty and ancestors with flag HasChildViewsToRefresh
But what about its ancestors? Ancestors aren’t marked as dirty, but instead receive a special marker called HasChildViewsToRefresh (Figure 17). This marker tells Angular that the component itself is clean, but it has children that need to be refreshed.
During change detection, Angular starts traversal from the root, skipping any OnPush components that aren’t dirty. However, when it encounters a component with the HasChildViewsToRefresh flag, it knows to continue down into its subtree. In this way, Angular bypasses clean components and focuses only on the path that leads to the consumer of the changed signal, ensuring that updates are applied exactly where they are needed (Figure 18).
It’s important to note that this optimization only works if the signal update isn’t triggered by mechanisms that already mark components as dirty (for example, event listener). In that case, the ancestors will be marked both as dirty and with the HasChildViewsToRefresh flag, which means Angular will check them as well.
Figure 18: “Local” change detection – Angular runs check detection only in component where signal value changed
Summing up this section: a trigger starts the process, and the strategy decides its scope. Now it’s time to see what preparation is needed before going zoneless.
Preparing for Zoneless
If we want to go zoneless, we first need to prepare our apps. It’s not just about removing Zone.js, we also need to make sure our components know how to notify Angular about changes. In other words, we have to replace the “magic” that Zone.js gave us with explicit signals, async pipes, or markForCheck calls. Once that’s in place, the transition becomes smooth and more predictable.
The very first step is to switch all components to the OnPush change detection strategy. Why? Because it immediately reveals what will stop working once Zone.js is gone. By forcing Angular to update only when explicitly notified, we can clearly see which parts of the app rely on Zone.js magic, and fix them before the actual migration.
Let’s look at some examples to see the most common issues you’ll run into, and which solutions will continue to work just fine. All examples below are shown using Angular 20.2, since that’s the version where zoneless mode is stable and safe to adopt.
Figure 19: View of an example component showing ship crew members
I’ll start with a simple example, a component that displays the ship crew. Initially, everything works fine, we fetch the crew list with an HTTP request, subscribe to it in the component, and assign the result to a crewMembers variable. The template shows the first loading message, and then the data.
Listing 1:
@Component({
selector: 'app-crew-widget',
imports: [AddCrewModalComponent, ConfirmDialogComponent],
templateUrl: './crew-widget.component.html',
styleUrls: ['./crew-widget.component.scss'],
})
export class CrewWidgetComponent implements OnInit {
protected crewMembers: CrewMember[] = [];
protected isLoading = true;
private crewService = inject(CrewService);
private destroyRef = inject(DestroyRef);
ngOnInit(): void {
this.crewService.getCrewMembers()
.pipe(
finalize(() => this.isLoading = false),
takeUntilDestroyed(this.destroyRef)
)
.subscribe(members => {
this.crewMembers = members;
});
}
}
But once we switch the component to the OnPush change detection strategy, things suddenly break. Instead of the crew list, we keep seeing the “loading” state, even though the HTTP call has already completed. Why does this happen?
Figure 20: View of an example component with loading state, when onPush strategy was turned on
Previously, Zone.js automatically tracked async tasks, such as HTTP requests. When the request finished, it triggered change detection for us. Without Zone.js, nothing notifies Angular that the data has arrived, so the UI never updates.
At this point, we have to trigger change detection ourselves. One option is to inject ChangeDetectorRef and call markForCheck after updating crewMembers. You can use it if you have to, but there are usually better options.
Listing 2 – markForCheck:
@Component({
selector: 'app-crew-widget',
imports: [AddCrewModalComponent, ConfirmDialogComponent],
templateUrl: './crew-widget.component.html',
styleUrls: ['./crew-widget.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class CrewWidgetComponent implements OnInit {
protected crewMembers: CrewMember[] = [];
private crewService = inject(CrewService);
private destroyRef = inject(DestroyRef);
private changeDetector = inject(ChangeDetectorRef);
ngOnInit(): void {
this.crewService.getCrewMembers()
.pipe(
finalize(() => this.isLoading = false),
takeUntilDestroyed(this.destroyRef)
)
.subscribe(members => {
this.crewMembers = members;
this.changeDetector.markForCheck();
});
}
Template:
<div class="crew-widget">
<div class="header">
<h2>Crew Members</h2>
</div>
@if(!isLoading) {
<ul class="crew-list">
@for(member of crewMembers; track member.id) {
<!-- Member content -->
}
@empty {
<li class="empty-crew">No crew members aboard yet.</li>
}
</ul>
} @else {
<div class="loading”>
<span>Loading crew members...</span>
</div>
}
</div>
A much better approach is to use the async pipe. It eliminates the need for manual subscription logic in your component and guarantees that Angular updates the view whenever data changes.
Listing 3 – Async Pipe:
@Component({
selector: 'app-crew-widget',
imports: [AddCrewModalComponent, ConfirmDialogComponent, AsyncPipe],
templateUrl: './crew-widget.component.html',
styleUrls: ['./crew-widget.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class CrewWidgetComponent {
private crewService = inject(CrewService);
crewMembers$ = this.crewService.getCrewMembers();
Template:
<div class="crew-widget">
<div class="header">
<h2>Crew Members</h2>
</div>
@let crewMembers = crewMembers$ | async;
@if(crewMembers) {
<ul class="crew-list">
@for(member of crewMembers; track member.id) {
<!-- Member content -->
}
@empty {
<li class="empty-crew">No crew members aboard yet.</li>
}
</ul>
} @else {
<div class="loading”>
<span>Loading crew members...</span>
</div>
}
</div>
We can also take advantage of toSignal. With it, we transform an observable into a signal inside our component. Whenever the observable emits a new value, the signal’s value is updated, and Angular reacts right away. Subscriptions are managed under the hood, so we avoid the extra boilerplate of manual unsubscribe logic. In the template, we just use our signal instead of the observable, but we have to call it with (), e.g., crewmember(), to get a signal’s value.
Listing 4 – Signals:
@Component({
selector: 'app-crew-widget',
imports: [AddCrewModalComponent, ConfirmDialogComponent, AsyncPipe],
templateUrl: './crew-widget.component.html',
styleUrls: ['./crew-widget.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class CrewWidgetComponent {
private crewService = inject(CrewService);
crewMembers = toSignal(this.crewService.getCrewMembers());
Template:
<div class="crew-widget">
<div class="header">
<h2>Crew Members</h2>
</div>
@if(crewMembers()) {
<ul class="crew-list">
@for(member of crewMembers(); track member.id) {
<!-- Member content -->
}
@empty {
<li class="empty-crew">No crew members aboard yet.</li>
}
</ul>
} @else {
<div class="loading”>
<span>Loading crew members...</span>
</div>
}
</div>
Beyond async pipes and signals, there’s also a new player: httpResource. It’s still experimental, but it already works seamlessly in a zoneless environment. Why? Because it doesn’t rely on Zone.js at all, it exposes its state through signals, making it a natural fit for the new change detection model.
Listing 5 – httpResource:
@Component({
selector: 'app-crew-widget',
imports: [AddCrewModalComponent, ConfirmDialogComponent, AsyncPipe],
templateUrl: './crew-widget.component.html',
styleUrls: ['./crew-widget.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class CrewWidgetComponent {
crew = httpResource<CrewMember[]>(() => `http://localhost:3000/crew`);
Template:
<div class="crew-widget">
<div class="header">
<h2>Crew Members</h2>
</div>
@if(crew.hasValue()) {
<ul class="crew-list">
@for(member of crew.value(); track member.id) {
<!-- Member content -->
}
@empty {
<li class="empty-crew">No crew members aboard yet.</li>
}
</ul>
}
@if(crew.isLoading()) {
<div class="loading ">
<span>Loading crew members...</span>
</div>
}
</div>
Another common pitfall comes from using setTimeout or setInterval. In a zoneless app, they no longer trigger change detection automatically. If your code relies on them, you’ll need to adjust it before migrating. Depending on the case, you can either call markForCheck to notify Angular manually or update a signal value directly. Just remember, for the signal update to refresh the UI, it has to be read in the template. If you’re working with an observable, ensure it’s consumed via the async pipe, so updates are picked up correctly.
Listing 6 – setInterval not triggering change detection in zoneless app:
rumStockValue = 100;
//some code
setInterval(() => {
this.rumStockValue = this.simulateRumConsumption();
}, 10000);
Listing 7 – setInterval with signal value change:
rumStockValue = signal(100);
//some code
setInterval(() => {
this.rumStockValue.set(this.simulateRumConsumption());
}, 10000);
Angular also gives us a safety net to verify that our app is truly zoneless-ready. With provideCheckNoChangesConfig({ exhaustive: true, interval: < milliseconds > }) to app.config.ts, we can enable a periodic debug check that ensures no state changes slip by unnoticed. If Angular detects a binding update that wouldn’t have been refreshed by zoneless change detection, it throws an ExpressionChangedAfterItHasBeenCheckedError. This helps us catch hidden dependencies on Zone.js before they become real issues in production.
Listing 8:
export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideZonelessChangeDetection(),
provideCheckNoChangesConfig({exhaustive: true, interval: 1000}),
provideRouter(routes),
provideHttpClient(),
]
};
Figure 21: Angular throws ExpressionChangedAfterItHasBeenCheckedError when a binding changes without notifying change detection
With this in place, we now have the full picture, how change detection behaves under different strategies, what pitfalls appear when removing Zone.js, and how tools like signals and the async pipe help us stay in control.
Conclusion: The End of an Era, the Start of Another
Zone.js has been part of Angular from the very beginning, bringing the “magic refresh” that automatically kept UIs in sync. For years, it simplified development and allowed developers to focus on building features instead of managing updates manually. But as applications grew larger and the web evolved, the hidden costs of that magic became harder to ignore: performance overhead, noisy debugging, compatibility issues, and extra complexity in testing.
That’s why the shift to zoneless marks an important milestone in Angular’s evolution. Developers can finally build apps without Zone.js, relying instead on signals, markForCheck, and OnPush-friendly patterns.
Zone.js was magic. Zoneless is mastery. With Angular 20.2, you can finally leave the overhead behind, build apps that are faster and easier to debug, and take full control of change detection. The future of Angular is zoneless. It’s time to join it.
References
🔍 Frequently Asked Questions (FAQ)
1. What is zoneless mode in Angular?
Zoneless mode in Angular removes the need for Zone.js to trigger change detection. Instead, updates rely on explicit triggers like signals, template events, or markForCheck()
.
2. Why did Angular move away from Zone.js?
Angular phased out Zone.js to improve performance, reduce bundle size, simplify debugging, and give developers explicit control over UI updates.
3. Which Angular version introduced stable zoneless mode?
Zoneless mode became stable with Angular 20.2. Earlier versions, like Angular 18, included experimental support for it.
4. How do you enable zoneless mode in a new Angular project?
You can enable zoneless mode using the --zoneless
flag when creating a project with Angular CLI 20.2+, or by choosing the zoneless option when prompted during setup.
5. How does change detection work without Zone.js?
In zoneless Angular, change detection is triggered by user events, signal updates, async pipes, or manual calls to markForCheck()
instead of monkey-patched async APIs.
6. What are the trade-offs of migrating to zoneless Angular?
You lose automatic change detection and must refactor to use signals or the OnPush
strategy. Legacy code tied to Zone.js may require adjustments.
7. What tools help validate a zoneless Angular app?
Use provideCheckNoChangesConfig({ exhaustive: true })
to enable runtime checks. It detects untracked changes and helps confirm you’re zoneless-ready.
8. What role does Angular 20 play in preparing for Angular 21?
Angular 20 introduces stable zoneless APIs and paves the way for deeper reactivity primitives and simplifications in Angular 21. The transition marks a strategic shift towards a leaner core and more modern development model.